diff options
Diffstat (limited to 'app/api/auth/[...nextauth]/saml/utils.ts')
| -rw-r--r-- | app/api/auth/[...nextauth]/saml/utils.ts | 405 |
1 files changed, 405 insertions, 0 deletions
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts new file mode 100644 index 00000000..7dfe9581 --- /dev/null +++ b/app/api/auth/[...nextauth]/saml/utils.ts @@ -0,0 +1,405 @@ +import { SAML, ValidateInResponseTo } from "@node-saml/node-saml"; +import { + getIDPMetadata, + normalizeCertificate, +} from "@/lib/saml/idp-metadata"; +import { + getSPMetadata, +} from "@/lib/saml/sp-metadata"; + +export interface SAMLProfile { + nameID?: string; + nameIDFormat?: string; + attributes?: Record<string, string[]>; + [key: string]: unknown; +} + +export interface SAMLUser { + id: string; + email: string; + name: string; + companyId?: number; + techCompanyId?: number; + domain?: string; +} + +// SAML 설정 생성 (sync 함수) - 환경변수 기반으로 변경했음 +export function createSAMLConfig() { + console.log("⚙️ Creating SAML configuration..."); + + try { + const idpMetadata = getIDPMetadata(); + const spMetadata = getSPMetadata(); + + console.log("📋 IdP Metadata loaded:", { + entityId: idpMetadata.entityId, + ssoUrl: idpMetadata.ssoUrl, + organization: idpMetadata.organization, + wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned, + }); + + console.log("📋 SP Metadata loaded:", { + entityId: spMetadata.entityId, + callbackUrl: spMetadata.callbackUrl, + authnRequestsSigned: spMetadata.authnRequestsSigned, + }); + + const config = { + callbackUrl: spMetadata.callbackUrl, + // IDP 메타데이터 기반 설정 + entryPoint: idpMetadata.ssoUrl, + // SP Entity ID + issuer: spMetadata.entityId, + // IDP 인증서 (정규화된 PEM 형식) + idpCert: normalizeCertificate(idpMetadata.certificate), + privateKey: process.env.SAML_SP_PRIVATE_KEY, + // IdP에서 요구하는 설정 + identifierFormat: idpMetadata.nameIdFormat, + signatureAlgorithm: "sha256" as const, + digestAlgorithm: "sha256", + // SP 메타데이터 설정 + decryptionPvk: process.env.SAML_SP_PRIVATE_KEY, + publicCert: process.env.SAML_SP_CERT, + // IdP 메타데이터 기반 설정 + wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned, + wantAssertionsSigned: spMetadata.wantAssertionsSigned, + validateInResponseTo: ValidateInResponseTo.never, + disableRequestedAuthnContext: true, + // HTTP-Redirect 바인딩 설정 + authnRequestBinding: undefined, // HTTP-Redirect (GET) 사용 (기본값) + skipRequestCompression: false, // Deflate 압축 사용 + // 추가 보안 설정 + acceptedClockSkewMs: 5000, // 5초 클럭 차이 허용 + forceAuthn: false, + // IDP Entity ID 설정 + idpIssuer: idpMetadata.entityId, + }; + + console.log("✅ SAML Config created:", { + callbackUrl: config.callbackUrl, + entryPoint: config.entryPoint, + issuer: config.issuer, + idpIssuer: config.idpIssuer, + identifierFormat: config.identifierFormat, + hasIdpCert: !!config.idpCert, + hasPrivateKey: !!config.privateKey, + hasPublicCert: !!config.publicCert, + wantAuthnResponseSigned: config.wantAuthnResponseSigned, + wantAssertionsSigned: config.wantAssertionsSigned, + }); + + return config; + } catch (error) { + console.error("💥 Failed to create SAML Config:", error); + throw error; + } +} + +// SAML AuthnRequest 생성 (서버 액션) +export async function createAuthnRequest(): Promise<string> { + "use server"; + + console.log("SSO STEP 2: Create AuthnRequest"); + + try { + const config = createSAMLConfig(); + console.log("SAML Config ready for AuthnRequest generation"); + + const saml = new SAML(config); + console.log("SAML instance created, generating authorize URL..."); + + const startTime = Date.now(); + const authorizeUrl = await saml.getAuthorizeUrlAsync( + "", // RelayState + undefined, // host + { + additionalParams: {}, + // additionalAuthorizeParams: {}, + } + ); + const endTime = Date.now(); + + // 🔍 SAML AuthnRequest 디코딩 및 분석 + try { + const urlObj = new URL(authorizeUrl); + const samlRequest = urlObj.searchParams.get("SAMLRequest"); + + if (samlRequest) { + console.log("SAML AuthnRequest 분석:"); + console.log("1️⃣ 원본 URL:", authorizeUrl); + console.log( + "2️⃣ URL 디코딩된 SAMLRequest:", + decodeURIComponent(samlRequest) + ); + + try { + // Base64 디코딩 + const base64DecodedBuffer = Buffer.from( + decodeURIComponent(samlRequest), + "base64" + ); + const base64DecodedString = base64DecodedBuffer.toString("utf-8"); + + // XML인지 확인 (XML은 '<'로 시작함) + if (base64DecodedString.trim().startsWith("<")) { + console.log("Base64 디코딩된 XML (압축 없음):"); + console.log("───────────────────────────────────"); + console.log(base64DecodedString); + console.log("───────────────────────────────────"); + + // XML 구조 분석 + const xmlLines = base64DecodedString + .split("\n") + .filter((line) => line.trim()); + console.log("XML 구조 요약:"); + xmlLines.forEach((line, index) => { + const trimmed = line.trim(); + if ( + trimmed.includes("<saml") || + trimmed.includes("<samlp") || + trimmed.includes("ID=") || + trimmed.includes("Destination=") + ) { + console.log(` ${index + 1}: ${trimmed}`); + } + }); + } else { + // XML이 아니면 Deflate 압축된 것으로 간주 + console.log( + "3️⃣ 압축된 바이너리 데이터 감지, Deflate 압축 해제 시도..." + ); + + try { + const zlib = require("zlib"); + const decompressed = zlib + .inflateRawSync(base64DecodedBuffer) + .toString("utf-8"); + console.log("Deflate 압축 해제된 XML:"); + console.log("───────────────────────────────────"); + console.log(decompressed); + console.log("───────────────────────────────────"); + + // XML 구조 분석 + const xmlLines = decompressed + .split("\n") + .filter((line) => line.trim()); + console.log("XML 구조 요약:"); + xmlLines.forEach((line, index) => { + const trimmed = line.trim(); + if ( + trimmed.includes("<saml") || + trimmed.includes("<samlp") || + trimmed.includes("ID=") || + trimmed.includes("Destination=") || + trimmed.includes("Issuer>") || + trimmed.includes("AssertionConsumerServiceURL=") + ) { + console.log(` ${index + 1}: ${trimmed}`); + } + }); + + // 중요한 정보 추출 + const idMatch = decompressed.match(/ID="([^"]+)"/); + const destinationMatch = decompressed.match( + /Destination="([^"]+)"/ + ); + const issuerMatch = decompressed.match( + /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/ + ); + const acsMatch = decompressed.match( + /AssertionConsumerServiceURL="([^"]+)"/ + ); + + console.log("추출된 핵심 정보:"); + console.log(` Request ID: ${idMatch ? idMatch[1] : "없음"}`); + console.log( + ` Destination: ${ + destinationMatch ? destinationMatch[1] : "없음" + }` + ); + console.log( + ` Issuer: ${issuerMatch ? issuerMatch[1] : "없음"}` + ); + console.log( + ` Callback URL: ${acsMatch ? acsMatch[1] : "없음"}` + ); + } catch (inflateError) { + console.log("❌ Deflate 압축 해제 실패:", inflateError.message); + console.log( + " 원본 바이너리 데이터 (hex):", + base64DecodedBuffer.toString("hex").substring(0, 100) + "..." + ); + } + } + } catch (decodeError) { + console.log("❌ Base64 디코딩 실패:", decodeError.message); + } + } + } catch (analysisError) { + console.log("⚠️ SAML AuthnRequest 분석 중 오류:", analysisError.message); + } + + console.log("✅ SAML AuthnRequest URL generated:", { + url: authorizeUrl.substring(0, 100) + "...", + fullUrlLength: authorizeUrl.length, + processingTime: `${endTime - startTime}ms`, + timestamp: new Date().toISOString(), + }); + + return authorizeUrl; + } catch (error) { + console.error("💥 Failed to create SAML AuthnRequest:", { + error: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + timestamp: new Date().toISOString(), + }); + throw error; + } +} + +// SAML Response 검증 및 파싱 (서버 액션) +export async function validateSAMLResponse( + samlResponse: string +): Promise<SAMLProfile> { + "use server"; + + console.log("🔍 Starting SAML Response validation..."); + console.log("📊 SAML Response info:", { + responseLength: samlResponse.length, + firstChars: samlResponse.substring(0, 50) + "...", + isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse), + timestamp: new Date().toISOString(), + }); + + // 실제 SAML 검증 수행 (기본값) + console.log( + "🔐 Using Real SAML validation (SAML_USE_MOCKUP=false or not set)" + ); + + try { + console.log("⚙️ Creating SAML instance for validation..."); + const saml = new SAML(createSAMLConfig()); + console.log("✅ SAML instance created, starting validation..."); + + const startTime = Date.now(); + const result = await saml.validatePostResponseAsync({ + SAMLResponse: samlResponse, + }); + const endTime = Date.now(); + + // node-saml 라이브러리는 { profile, loggedOut } 형태로 반환 + const profile = result.profile; + if (!profile) { + throw new Error("No profile returned from SAML validation"); + } + + // SAMLProfile 형태로 변환 + const samlProfile: SAMLProfile = { + nameID: profile.nameID, + nameIDFormat: profile.nameIDFormat, + attributes: profile.attributes || {}, + }; + + console.log("✅ Real SAML Profile validated successfully:", { + nameID: samlProfile.nameID, + nameIDFormat: samlProfile.nameIDFormat, + attributeCount: Object.keys(samlProfile.attributes || {}).length, + attributes: Object.keys(samlProfile.attributes || {}), + processingTime: `${endTime - startTime}ms`, + timestamp: new Date().toISOString(), + }); + + return samlProfile; + } catch (error) { + console.error("❌ Real SAML validation error:", { + error: error instanceof Error ? error.message : "Unknown error", + stack: error instanceof Error ? error.stack : undefined, + samlResponseLength: samlResponse.length, + timestamp: new Date().toISOString(), + }); + throw new Error( + `SAML validation failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } +} + +// SAML Profile을 User 객체로 변환 (sync 함수) +export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser { + console.log("🔄 Mapping SAML profile to user:", { + nameID: profile.nameID, + attributes: profile.attributes, + }); + + // 기본적으로 nameID를 사용하거나 attributes에서 추출 + const id = + profile.nameID || + profile.attributes?.uid?.[0] || + profile.attributes?.employeeNumber?.[0] || + ""; + const email = + profile.attributes?.email?.[0] || + profile.attributes?.mail?.[0] || + profile.nameID || + ""; + // UTF-8 이름 처리 개선 + let name = + profile.attributes?.displayName?.[0] || + profile.attributes?.cn?.[0] || + profile.attributes?.name?.[0] || + (profile.attributes?.givenName?.[0] && profile.attributes?.sn?.[0] + ? profile.attributes.givenName[0] + " " + profile.attributes.sn[0] + : "") || + ""; + + // UTF-8 문자열 정규화 및 검증 + if (name && typeof name === "string") { + name = name.normalize("NFC").trim(); + + // 한글이 깨진 경우 감지 및 로그 + const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(name); + if (hasInvalidChars) { + console.warn("⚠️ Invalid UTF-8 characters detected in name:", { + originalName: name, + charCodes: [...name].map((c) => c.charCodeAt(0)), + hexDump: [...name] + .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0")) + .join(""), + }); + } + } + + // 회사 정보는 SSO 로그인 시 없음 + const companyId = undefined; + const techCompanyId = undefined; + const domain = 'evcp'; + + const user = { + id, + email, + name: name.trim(), + companyId, + techCompanyId, + domain, + }; + + console.log("👤 Mapped user object:", user); + + return user; +} + +// SAML 로그아웃 URL 생성 (서버 액션) +// 로그아웃 지원 안함. 일단 구조만 유사하게 작성해둠. +export async function createLogoutRequest(nameID: string): Promise<string> { + "use server"; + + const saml = new SAML(createSAMLConfig()); + return await saml.getLogoutUrlAsync( + nameID, + "", // RelayState + { + nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + } + ); +} |
